Ku analizie nowo poznanych narzędzi, pod lupę wezmę zbiór analizowany w części zespołowej - **Red Wine Quality**.
import pandas as pd
import numpy as np
import math
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC
import dalex as dx
df = pd.read_csv("winequality-red.csv")
df["quality"] = df["quality"].apply(lambda x: 1 if x > 5 else 0) # wina o ocenie powyzej 5/10 ---> dobre
df.head()
| fixed acidity | volatile acidity | citric acid | residual sugar | chlorides | free sulfur dioxide | total sulfur dioxide | density | pH | sulphates | alcohol | quality | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7.4 | 0.70 | 0.00 | 1.9 | 0.076 | 11.0 | 34.0 | 0.9978 | 3.51 | 0.56 | 9.4 | 0 |
| 1 | 7.8 | 0.88 | 0.00 | 2.6 | 0.098 | 25.0 | 67.0 | 0.9968 | 3.20 | 0.68 | 9.8 | 0 |
| 2 | 7.8 | 0.76 | 0.04 | 2.3 | 0.092 | 15.0 | 54.0 | 0.9970 | 3.26 | 0.65 | 9.8 | 0 |
| 3 | 11.2 | 0.28 | 0.56 | 1.9 | 0.075 | 17.0 | 60.0 | 0.9980 | 3.16 | 0.58 | 9.8 | 1 |
| 4 | 7.4 | 0.70 | 0.00 | 1.9 | 0.076 | 11.0 | 34.0 | 0.9978 | 3.51 | 0.56 | 9.4 | 0 |
Dla odmiany dokonam klasyfikacji binarnej z pomocą domyślnego Support Vector Machine.
X_train, X_test, y_train, y_test = train_test_split(df.drop("quality", axis=1), df["quality"],
test_size = 0.25, random_state = 1613)
svc = SVC(kernel = 'linear')
svc.fit(X_train, y_train)
y_pred = svc.predict(X_test)
print("Accuracy: ", sum(y_test == y_pred)/y_test.shape[0]*100, "%", sep = "")
Accuracy: 75.0%
Wynik oczywiście nieco gorszy od rewelacyjnego XGBoost.
Przyjrzyjmy się 32. obserwacji.
X_train.iloc[31, :]
fixed acidity 6.9000 volatile acidity 0.3600 citric acid 0.2500 residual sugar 2.4000 chlorides 0.0980 free sulfur dioxide 5.0000 total sulfur dioxide 16.0000 density 0.9964 pH 3.4100 sulphates 0.6000 alcohol 10.1000 Name: 293, dtype: float64
print("Przewidziane: ", y_pred[31], ", oczekiwane: ", list(y_test)[31], sep = "")
Przewidziane: 1, oczekiwane: 1
Zainicjujmy nasz explainer (wytłumaczator?).
explainer = dx.Explainer(svc, X_train, y_train)#, predict_function=predict_function)
Preparation of a new explainer is initiated -> data : 1199 rows 11 cols -> target variable : Parameter 'y' was a pandas.Series. Converted to a numpy.ndarray. -> target variable : 1199 values -> model_class : sklearn.svm._classes.SVC (default) -> label : Not specified, model's class short name will be used. (default) -> predict function : <function yhat_default at 0x000002C3F827AA60> will be used (default) -> predict function : Accepts pandas.DataFrame and numpy.ndarray. -> predicted values : min = 0.0, mean = 0.497, max = 1.0 -> model type : classification will be used (default) -> residual function : difference between y and yhat (default) -> residuals : min = -1.0, mean = 0.0359, max = 1.0 -> model_info : package sklearn A new explainer has been created!
explainer.predict(X_train)[31]
1
Wpierw uruchommy funkcję predict_parts() z domyślnymi parametrami. Dostaniemy w ten sposób standardowy break down.
pp_bd = explainer.predict_parts(X_train.iloc[31,:])
pp_bd
| variable_name | variable_value | variable | cumulative | contribution | sign | position | label | |
|---|---|---|---|---|---|---|---|---|
| 0 | intercept | 1 | intercept | 0.497081 | 0.497081 | 1.0 | 10 | SVC |
| 1 | alcohol:volatile acidity | 10.1:0.36 | alcohol:volatile acidity = 10.1:0.36 | 0.788157 | 0.291076 | 1.0 | 9 | SVC |
| 2 | total sulfur dioxide | 16.0 | total sulfur dioxide = 16.0 | 0.979983 | 0.191827 | 1.0 | 8 | SVC |
| 3 | fixed acidity | 6.9 | fixed acidity = 6.9 | 0.886572 | -0.093411 | -1.0 | 7 | SVC |
| 4 | free sulfur dioxide | 5.0 | free sulfur dioxide = 5.0 | 0.804837 | -0.081735 | -1.0 | 6 | SVC |
| 5 | sulphates | 0.6 | sulphates = 0.6 | 0.727273 | -0.077565 | -1.0 | 5 | SVC |
| 6 | chlorides:citric acid | 0.098:0.25 | chlorides:citric acid = 0.098:0.25 | 1.000000 | 0.272727 | 1.0 | 4 | SVC |
| 7 | pH | 3.41 | pH = 3.41 | 1.000000 | 0.000000 | 0.0 | 3 | SVC |
| 8 | density | 0.9964 | density = 0.9964 | 1.000000 | 0.000000 | 0.0 | 2 | SVC |
| 9 | residual sugar | 2.4 | residual sugar = 2.4 | 1.000000 | 0.000000 | 0.0 | 1 | SVC |
| 10 | prediction | 1.000000 | 1.000000 | 1.0 | 0 | SVC |
Wszystko fajnie, ale... czemu niektóre wiersze odpowiadają za pary kolumn??
Okazuje się, ża pakiet Dalex w przypadku wykrycia pewnych istotnych interakcji między kolumnami, dla niektórych wywołań łączy je w pary i ukazuje wytłumaczenie, demonstrując je jako jedność. Tak oto w tym wypadku mamy styczność z takowym "połączeniem" zmiennych alcohol i volatile acidity, a także chlorides i citric acid. Owe twory nie są przypadkowe - z macierzy korelacji przytoczonej w eksploracji zbioru danych można było zaobserwować istotne wartości w przypadku obu par.
Jak prezentuje się nasz wykres?
pp_bd.plot()
Stosunkowo niska kwasowość lotna przy typowej zawartości alkoholu dla wina (przynajmniej w porównaniu z innymi obserwacjami) wpierw poskutkowała znaczącym wpływem na uznanie trunku za dobry jakościowo. Tym bardziej szanse na to zwiększyła całkowita zawartość dwutlenku siarki, choć na ocenę pozytywną negatywnie wpłynęły wartości kwasowości stałej, zawartości cząsteczek dwutlenku siarki niezwiązanych z innymi cząsteczkami i siarczanów. Biorąc pod uwagę wszystkie wymienione dotychczas czynniki, kluczowe okazało się kolumny odpowiadające za sole i kwas cytrynowy - te zaważyły o przewidywanej dobrej ocenie ekspertów.
Co zrobić, aby uzyskać wytłumaczalność każdej zmiennej decyzyjnej osobno? Okazuje się, że wystarczy zmienić domyślny parametr type na break_down. Można stąd wywnioskować, że standardowa implementacja nie zawiera tak naprawdę najprostszej wersji algorytmu, ale jej modyfikację - chociażby o efekt uzyskany wyżej.
pp_bd2 = explainer.predict_parts(X_train.iloc[31,:], type = "break_down")
pp_bd2
| variable_name | variable_value | variable | cumulative | contribution | sign | position | label | |
|---|---|---|---|---|---|---|---|---|
| 0 | intercept | 1 | intercept | 0.497081 | 0.497081 | 1.0 | 12 | SVC |
| 1 | volatile acidity | 0.36 | volatile acidity = 0.36 | 0.654712 | 0.157631 | 1.0 | 11 | SVC |
| 2 | total sulfur dioxide | 16.0 | total sulfur dioxide = 16.0 | 0.815680 | 0.160967 | 1.0 | 10 | SVC |
| 3 | density | 0.9964 | density = 0.9964 | 0.815680 | 0.000000 | 0.0 | 9 | SVC |
| 4 | residual sugar | 2.4 | residual sugar = 2.4 | 0.815680 | 0.000000 | 0.0 | 8 | SVC |
| 5 | citric acid | 0.25 | citric acid = 0.25 | 0.807339 | -0.008340 | -1.0 | 7 | SVC |
| 6 | chlorides | 0.098 | chlorides = 0.098 | 0.794829 | -0.012510 | -1.0 | 6 | SVC |
| 7 | pH | 3.41 | pH = 3.41 | 0.788991 | -0.005838 | -1.0 | 5 | SVC |
| 8 | sulphates | 0.6 | sulphates = 0.6 | 0.783987 | -0.005004 | -1.0 | 4 | SVC |
| 9 | free sulfur dioxide | 5.0 | free sulfur dioxide = 5.0 | 0.669725 | -0.114262 | -1.0 | 3 | SVC |
| 10 | fixed acidity | 6.9 | fixed acidity = 6.9 | 0.578816 | -0.090909 | -1.0 | 2 | SVC |
| 11 | alcohol | 10.1 | alcohol = 10.1 | 1.000000 | 0.421184 | 1.0 | 1 | SVC |
| 12 | prediction | 1.000000 | 1.000000 | 1.0 | 0 | SVC |
I tutaj już rzeczywiście każda zmienna jest uwzględniona oddzielnie!
Co interesujące i bardzo zastanawiające... tu alcohol wylądował na samym dnie wytłumaczalności :O
pp_bd2.plot()
Jak widać pakiet nie jest idealny - powyżej można zaobserwować błąd wizualizacji. Końcowy wynik predykcji (1) nie odpowiada temu, co sugeruje oś. A może po prostu zostało zwrócone prawdopodobieństwo, że wino zostało ocenione jako dobre, zamiast binarnego outputu? Tego nie wiem, ale prezentuje się niejasno na pewno.
A jak biblioteka i zawarte w niej algorytmy poradzą sobie z Shapley Values?
pp_shap = explainer.predict_parts(X_train.iloc[31,:], type = "shap")
pp_shap
| variable | contribution | variable_name | variable_value | sign | label | B | |
|---|---|---|---|---|---|---|---|
| 0 | alcohol = 10.1 | -0.092577 | alcohol | 10.1000 | -1.0 | SVC | 1 |
| 1 | sulphates = 0.6 | -0.044204 | sulphates | 0.6000 | -1.0 | SVC | 1 |
| 2 | volatile acidity = 0.36 | 0.412844 | volatile acidity | 0.3600 | 1.0 | SVC | 1 |
| 3 | density = 0.9964 | 0.000000 | density | 0.9964 | 0.0 | SVC | 1 |
| 4 | free sulfur dioxide = 5.0 | -0.175146 | free sulfur dioxide | 5.0000 | -1.0 | SVC | 1 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 6 | sulphates = 0.6 | -0.011510 | sulphates | 0.6000 | -1.0 | SVC | 0 |
| 7 | pH = 3.41 | -0.006972 | pH | 3.4100 | -1.0 | SVC | 0 |
| 8 | chlorides = 0.098 | -0.003036 | chlorides | 0.0980 | -1.0 | SVC | 0 |
| 9 | residual sugar = 2.4 | -0.000500 | residual sugar | 2.4000 | -1.0 | SVC | 0 |
| 10 | density = 0.9964 | 0.000000 | density | 0.9964 | 0.0 | SVC | 0 |
286 rows × 7 columns
pp_shap.plot()
Tu już wszystko gra! Największy wpływ na pozytywną predykcję okazały się mieć kwasowość lotna, zawartość dwutlenku siarki i moc alkoholowa. Rezultat ten odpowiada trzem najbardziej znaczącym zmiennom dla standardowej metody break down. Nic też dziwnego, że wszystkie te trzy kolumny mają korzystny wkład ku dobrej ocenie ekspertów - z czegoś do tej 1 trzeba dojść.
Zobaczmy dekompozycję predykcji dla wszysktich obserwacji o indeksach bądących potęgami dwójki. Na tym etapie skupię się tylko na wersji bezparametrowej, przedstawionej na zajęciach. Tak jak w podpunkcie drugim, poskutkuje ona niekiedy "łączeniem" par kolumn w jedność [w kwestii wytłumaczalności].
Zbadam obserwację o numerach będących potęgami dwójki - a, tak dla losowości.
indexes = [2**x-1 for x in range(0, round(math.log2(df.shape[0])))]
indexes
[0, 1, 3, 7, 15, 31, 63, 127, 255, 511, 1023]
Dla każdego indeksu utwórz wykres dla bezparametrowego break down.
for which in indexes:
print("ID =", which)
pp_bd = explainer.predict_parts(X_train.iloc[which,:])
pp_bd.plot()
ID = 0
ID = 1
ID = 3
ID = 7
ID = 15
ID = 31
ID = 63
ID = 127
ID = 255
ID = 511
ID = 1023
Wnioski
Zweryfikujmy jeszcze wynik dla drugiej poznanej na zajęciach metody.
for which in indexes:
print("ID =", which)
pp_shap = explainer.predict_parts(X_train.iloc[which,:], type = "shap")
pp_shap.plot()
ID = 0
ID = 1
ID = 3
ID = 7
ID = 15
ID = 31
ID = 63
ID = 127
ID = 255
ID = 511
ID = 1023
Wnioski końcowe